"""
Image File Organizer - Professional Image Management Tool
==========================================================
File      : 02-split-images-based-on-type-V01.py
Version   : 1.0.0
Author    : Yahya Nazer
Copyright : (c) 2025 Chatbizdb.com - Yahya Nazer
License   : Proprietary
Email     : contact@chatbizdb.com
Status    : Production
Date      : 2026-05-13

Objective
---------
Let the user select a source folder via a GUI dialog, scan its top-level
files for supported image formats, and COPY each image into a type-specific
subfolder inside a new timestamped output folder.  Original files are never
moved or deleted.

How the Organizer Works
------------------------
    1. The user selects a folder (e.g. /Photos/Vacation).
    2. A timestamped output folder is created inside a C-Reports folder
       that sits beside the source:
           /Photos/C-Reports/Vacation-organized-YYYY-MM-DD--HH-MM/
    3. One subfolder is created per file type found:
           /Photos/Vacation-organized-YYYY-MM-DD--HH-MM/JPG/
           /Photos/Vacation-organized-YYYY-MM-DD--HH-MM/NEF/
           /Photos/Vacation-organized-YYYY-MM-DD--HH-MM/PNG/
    4. Every image is copied (using shutil.copy2) into its type subfolder.
    5. Duplicate filenames are auto-renamed with a counter suffix.

Output Folder Layout
---------------------
    <parent of source>/
    +-- C-Reports/
        +-- <source_folder_name>-organized-YYYY-MM-DD--HH-MM/
            +-- JPG/
            |   +-- photo1.jpg
            |   +-- photo2.jpg
            +-- NEF/
            |   +-- raw1.nef
            +-- PNG/
                +-- graphic.png

IMPORTANT: This script scans only the TOP-LEVEL files of the selected folder.
It does NOT recurse into subfolders.  Use os.walk() instead of os.listdir()
if recursive scanning is needed in a future version.

os.listdir() vs os.walk()
---------------------------
    os.listdir(folder)
        Returns a flat list of names (files AND subdirectories) in folder.
        Does NOT recurse.  Subdirectory names appear in the list but are
        filtered out with os.path.isdir().

    os.walk(folder)
        Yields (root, dirs, files) for every directory in the tree.
        Recursively visits all subdirectories.

    This script deliberately uses os.listdir() so that only the images
    directly in the selected folder are organised -- not images in
    nested albums or subfolders.

shutil.copy2() vs shutil.copy()
----------------------------------
    shutil.copy(src, dst)   : Copies file content and permissions only.
    shutil.copy2(src, dst)  : Copies content, permissions, AND timestamps
                               (mtime, atime).

    copy2 is used here so the copied images retain their original
    creation and modification dates, not the date the copy was made.
    This matters for photo workflows where sorting by date is common.

Data Structures
----------------
    image_files (dict)
        Returned by get_image_files().
        Key   : lowercase extension string, e.g. '.jpg', '.nef'
        Value : list of filenames with that extension, e.g. ['img1.jpg', ...]

        Example:
            {
                '.jpg': ['photo1.jpg', 'photo2.jpg'],
                '.nef': ['raw1.nef'],
                '.png': ['graphic.png'],
            }

    subfolders (dict)
        Returned by create_subfolders().
        Key   : lowercase extension string (same keys as image_files)
        Value : full absolute path to the created subfolder

        Example:
            {
                '.jpg': '/Photos/Vacation-organized-.../JPG',
                '.nef': '/Photos/Vacation-organized-.../NEF',
            }

Subfolder Name from Extension
-------------------------------
    ext[1:].upper()
        ext is the lowercase extension including the dot: '.jpg'
        [1:]    strips the leading dot: 'jpg'
        .upper() converts to uppercase: 'JPG'
    So each subfolder is named after the format in uppercase: JPG, NEF, PNG.

Duplicate Filename Handling
-----------------------------
If two files share the same filename (possible when scanning multiple
subdirectories -- future versions), a counter suffix is appended:
    photo.jpg -> photo_1.jpg -> photo_2.jpg ...

The while loop checks os.path.exists(dest_path) before incrementing,
so it always finds the first available name without overwriting.

Supported Image Formats
------------------------
    Standard : .jpg .jpeg .png .gif .bmp .tiff .tif .webp .svg .ico
    RAW      : .raf .cr2 .cr3 .nef .arw .dng .orf .rw2 .pef .srw
               .x3f .raw .rwl .mrw .dcr .kdc .srf .sr2 .erf .mef
               .mos .ptx .pxn .r3d .fff .iiq .k25 .cap .dcs .drf .3fr

    All extensions are matched case-insensitively (.JPG == .jpg).

Dependencies
-------------
Standard library only -- no pip install required:
    os, sys, shutil, datetime, tkinter

Function Summary
-----------------
    Function                Purpose
    ----------------------  --------------------------------------------------
    get_folder_selection()  GUI folder-picker; return the selected path string.
    create_organized_folder() Create the timestamped output folder beside source.
    get_image_files()       Scan top-level files; return {ext: [filenames]} dict.
    create_subfolders()     Create one subfolder per extension; return path dict.
    copy_files()            Copy all images to their subfolders; return counts.
    main()                  Orchestrate all steps end-to-end.

Step-by-Step Flow (inside main())
------------------------------------
Step 1  : GUI folder-picker: select the source folder containing images.
Step 2  : Create the timestamped organized output folder beside the source.
Step 3  : Scan source folder top-level files; group by extension.
Step 4  : Create one named subfolder per discovered extension.
Step 5  : Copy every image to its type subfolder (with duplicate handling).
Step 6  : Print the completion summary and show a GUI info dialog.

Notes
-----
- The output folder is created inside C-Reports/, which itself sits in the
  parent of the source folder, keeping the source directory completely clean.
- ext[1:].upper() strips the dot and uppercases: '.jpg' -> 'JPG'.
- sys.stdout.reconfigure() is called at module level so all output
  (including from import-time code) uses UTF-8.
- A completion messagebox is shown after the copy so the user gets a
  clear signal that the operation is finished.
"""

__version__    = '1.0.0'
__author__     = 'Yahya Nazer'
__copyright__  = 'Copyright (c) 2025 Chatbizdb.com - Yahya Nazer'
__license__    = 'Proprietary'
__maintainer__ = 'Yahya Nazer'
__email__      = 'contact@chatbizdb.com'
__status__     = 'Production'

# ===========================================================================
# Imports
# ===========================================================================
import sys

# Reconfigure stdout to UTF-8 so filenames with accented/special characters
# print correctly on Windows terminals.
if hasattr(sys.stdout, 'reconfigure'):
    sys.stdout.reconfigure(encoding='utf-8')

import os                           # File system operations and path handling
import shutil                       # High-level file copy (copy2 preserves metadata)
from datetime import datetime       # Timestamp generation for output folder name
import tkinter as tk                # GUI toolkit for folder-picker and info dialog
from tkinter import filedialog, messagebox  # Folder dialog and completion popup


# ===========================================================================
# Complete set of supported image file extensions (lowercase, dot-prefixed)
#
# Stored as a Python set for O(1) membership tests.
# All comparisons use ext.lower() so .JPG, .Jpg, and .jpg all match.
# ===========================================================================
IMAGE_EXTENSIONS = {
    # Standard compressed / lossless formats
    '.jpg', '.jpeg', '.png', '.gif', '.bmp',
    '.tiff', '.tif', '.webp', '.svg', '.ico',
    # RAW camera formats
    '.raf',             # Fujifilm
    '.cr2', '.cr3',     # Canon
    '.nef',             # Nikon
    '.arw',             # Sony
    '.dng',             # Adobe Digital Negative (universal)
    '.orf',             # Olympus
    '.rw2',             # Panasonic
    '.pef',             # Pentax
    '.srw',             # Samsung
    '.x3f',             # Sigma
    '.raw', '.rwl',     # Leica
    '.mrw',             # Konica-Minolta
    '.dcr', '.kdc',     # Kodak
    '.srf', '.sr2',     # Sony older formats
    '.erf',             # Epson
    '.mef',             # Mamiya
    '.mos',             # Leaf / Capture One
    '.ptx', '.pxn',     # Pentax alternative
    '.r3d',             # Red cinema camera
    '.fff',             # Hasselblad
    '.iiq',             # Phase One
    '.k25',             # Kodak DCS series
    '.cap',             # Phase One Capture
    '.dcs',             # Kodak DCS
    '.drf',             # Kodak RAW
    '.3fr',             # Hasselblad
}


# ===========================================================================
# Function: get_folder_selection()
# ===========================================================================
def get_folder_selection() -> str | None:
    """
    Open a GUI folder-picker dialog and return the selected folder path.

    Uses tkinter's askdirectory() which opens the OS-native folder browser.
    Returns None if the user cancels the dialog.

    root.withdraw() hides the blank tkinter root window so only the folder
    browser dialog is visible to the user.

    Returns:
        str | None: Path string of the selected folder, or None if cancelled.
    """
    print('=== STEP 1: Folder Selection ===')

    root = tk.Tk()
    root.withdraw()                 # Hide the blank root window immediately

    folder_path = filedialog.askdirectory(
        title='Select folder containing images to organize'
    )

    if not folder_path:
        print('[ERROR] No folder selected. Exiting.')
        return None

    print(f'[INFO]  Selected folder: {folder_path}')
    return folder_path


# ===========================================================================
# Function: create_organized_folder()
# ===========================================================================
def create_organized_folder(source_folder: str) -> str | None:
    """
    Create the timestamped output folder as a SIBLING of the source folder.

    Output folder name format:
        C-Reports/<source_folder_name>-organized-YYYY-MM-DD--HH-MM

    Example:
        source_folder = /Photos/Vacation
        output folder = /Photos/C-Reports/Vacation-organized-2026-05-13--14-30

    C-Reports is created inside the PARENT of the source folder so the
    source directory remains clean and unmodified.

    Path construction:
        os.path.dirname(source_folder)   -> parent directory (/Photos)
        os.path.basename(source_folder)  -> 'Vacation' (folder name only)
        C-Reports folder                 -> /Photos/C-Reports/
        Full output path                 -> /Photos/C-Reports/Vacation-organized-...

    os.makedirs(path, exist_ok=True)
        Creates the folder and any missing parent directories.
        exist_ok=True means no error is raised if the folder already exists
        (safe to call on re-runs within the same minute).

    Args:
        source_folder (str): Absolute path of the selected image folder.

    Returns:
        str | None: Full path of the created output folder, or None on error.
    """
    print('\n=== STEP 2: Creating Organized Folder ===')

    parent_dir       = os.path.dirname(source_folder)      # Parent directory
    folder_name      = os.path.basename(source_folder)     # Source folder name only
    timestamp        = datetime.now().strftime('%Y-%m-%d--%H-%M')
    new_folder_name  = f'{folder_name}-organized-{timestamp}'
    c_results_dir    = os.path.join(parent_dir, 'C-Reports')    # C-Reports beside source
    new_folder_path  = os.path.join(c_results_dir, new_folder_name)

    try:
        os.makedirs(new_folder_path, exist_ok=True)
        print(f'[INFO]  Created organized folder: {new_folder_path}')
        return new_folder_path

    except Exception as e:
        print(f'[ERROR] Could not create organized folder: {e}')
        return None


# ===========================================================================
# Function: get_image_files()
# ===========================================================================
def get_image_files(folder_path: str) -> dict:
    """
    Scan the TOP-LEVEL files of folder_path and group them by extension.

    IMPORTANT: This function uses os.listdir(), which returns only the
    direct contents of the folder -- NOT recursive.  Subfolders are
    explicitly skipped with os.path.isdir().

    Extension matching:
        os.path.splitext(filename) splits 'photo.JPG' into ('photo', '.JPG').
        ext.lower() normalises '.JPG' -> '.jpg' for consistent dict keying.
        The lowercase extension is then checked against IMAGE_EXTENSIONS.

    Return structure:
        {
            '.jpg': ['photo1.jpg', 'photo2.jpg'],
            '.nef': ['raw1.NEF'],              <- stored with original case
            '.png': ['graphic.png'],
        }
        Keys are LOWERCASE extensions (for consistent lookup).
        Values are lists of filenames as they appear in the directory.

    A summary is printed showing how many files of each type were found,
    helping the user verify the scan before the copy starts.

    Args:
        folder_path (str): Absolute path of the folder to scan.

    Returns:
        dict: {lowercase_extension: [filename, ...]} for each image type found.
              Returns {} if no images found or an error occurs.
    """
    print('\n=== STEP 3: Scanning for Image Files ===')
    print(f'[INFO]  Scanning (top-level only): {folder_path}')

    image_files = {}    # {'.jpg': ['photo1.jpg', 'photo2.jpg'], ...}
    total_files = 0

    try:
        for filename in os.listdir(folder_path):
            file_path = os.path.join(folder_path, filename)

            # Skip subdirectories -- this script is not recursive
            if os.path.isdir(file_path):
                continue

            # Extract and normalise the file extension
            _, ext     = os.path.splitext(filename)    # e.g. ('photo', '.JPG')
            ext_lower  = ext.lower()                   # '.jpg'

            if ext_lower in IMAGE_EXTENSIONS:
                # First file of this type: create the list
                if ext_lower not in image_files:
                    image_files[ext_lower] = []

                image_files[ext_lower].append(filename)
                total_files += 1

        print(f'[INFO]  Total image files found: {total_files}')

        # Summary by type
        for ext, files in image_files.items():
            # ext[1:].upper() -> strip dot, uppercase: '.jpg' -> 'JPG'
            print(f'[INFO]    {ext[1:].upper():>6}: {len(files)} file(s)')

        return image_files

    except Exception as e:
        print(f'[ERROR] Error scanning folder: {e}')
        return {}


# ===========================================================================
# Function: create_subfolders()
# ===========================================================================
def create_subfolders(organized_folder: str, image_files: dict) -> dict:
    """
    Create one named subfolder per image extension inside the output folder.

    Subfolder name derivation:
        ext[1:].upper()
            ext is the lowercase extension with its leading dot: '.jpg'
            [1:]     strips the dot: 'jpg'
            .upper() converts to uppercase: 'JPG'
        So '.nef' becomes 'NEF', '.cr2' becomes 'CR2', etc.

    Returns a dict mapping each extension to its created subfolder path,
    so copy_files() can look up the destination for each file type without
    reconstructing the path.

    Args:
        organized_folder (str): Full path of the timestamped output folder.
        image_files      (dict): {ext: [filenames]} from get_image_files().

    Returns:
        dict: {lowercase_extension: subfolder_absolute_path}
              Extensions that failed to create their subfolder are excluded.
    """
    print('\n=== STEP 4: Creating Subfolders ===')

    created_folders = {}    # {'.jpg': '/output/JPG', ...}

    for ext in image_files.keys():
        # Convert '.jpg' -> 'JPG' for the subfolder name
        folder_name    = ext[1:].upper()
        subfolder_path = os.path.join(organized_folder, folder_name)

        try:
            os.makedirs(subfolder_path, exist_ok=True)
            created_folders[ext] = subfolder_path
            print(f'[INFO]  Created subfolder: {folder_name}/')

        except Exception as e:
            print(f'[ERROR] Could not create subfolder {folder_name}: {e}')

    return created_folders


# ===========================================================================
# Function: copy_files()
# ===========================================================================
def copy_files(source_folder: str,
               image_files: dict,
               subfolders: dict) -> tuple:
    """
    Copy every image file into its corresponding type subfolder.

    shutil.copy2(src, dst):
        Copies the file content AND preserves timestamps (mtime, atime).
        This is important for photo workflows where files are later sorted
        by original date.  shutil.copy() would not preserve the dates.

    Duplicate filename handling:
        If the destination already contains a file with the same name,
        a counter suffix is appended to the base name:
            photo.jpg -> photo_1.jpg -> photo_2.jpg -> ...

        The while loop increments the counter until os.path.exists()
        returns False, guaranteeing a free filename before copying.

    Progress printing:
        Every successfully copied file is reported.  Failed files print
        an error but do not abort the loop -- all remaining files continue.

    Args:
        source_folder (str):  Absolute path of the source image folder.
        image_files   (dict): {ext: [filenames]} from get_image_files().
        subfolders    (dict): {ext: subfolder_path} from create_subfolders().

    Returns:
        tuple: (copied_count, failed_count)
            copied_count (int): Number of files successfully copied.
            failed_count (int): Number of files that failed to copy.
    """
    print('\n=== STEP 5: Copying Files ===')

    copied_count = 0
    failed_count = 0

    for ext, files in image_files.items():

        # Guard: subfolder should always exist; log and skip if missing
        if ext not in subfolders:
            print(f'[WARN]  No subfolder found for {ext} files -- skipping.')
            continue

        dest_folder = subfolders[ext]
        print(f'\n[INFO]  Copying {len(files)} {ext[1:].upper()} file(s) '
              f'to {dest_folder}...')

        for filename in files:
            source_path = os.path.join(source_folder, filename)
            dest_path   = os.path.join(dest_folder,   filename)

            try:
                # Duplicate detection: resolve filename conflict before copying
                if os.path.exists(dest_path):
                    base, extension = os.path.splitext(filename)
                    counter         = 1

                    # Increment counter until a free destination name is found
                    while os.path.exists(dest_path):
                        new_filename = f'{base}_{counter}{extension}'
                        dest_path    = os.path.join(dest_folder, new_filename)
                        counter     += 1

                    print(f'[WARN]  Duplicate renamed: {filename} -> '
                          f'{os.path.basename(dest_path)}')

                # Copy the file preserving all metadata (timestamps, permissions)
                shutil.copy2(source_path, dest_path)
                copied_count += 1
                print(f'[INFO]  Copied: {filename}')

            except Exception as e:
                failed_count += 1
                print(f'[ERROR] Failed to copy {filename}: {e}')

    return copied_count, failed_count


# ===========================================================================
# Function: main()
# ===========================================================================
def main() -> None:
    """
    Orchestrate the full image organisation workflow end-to-end.

    Steps:
        1. GUI folder-picker: select the source folder.
        2. Create the timestamped organised output folder beside the source.
        3. Scan top-level files; group by extension into image_files dict.
        4. Create one named subfolder per extension inside the output folder.
        5. Copy all images to their type subfolders (with duplicate handling).
        6. Print completion summary and show a GUI info dialog.
    """
    print('IMAGE FILE ORGANIZER')
    print('=' * 50)

    # Step 1 -- Select source folder
    source_folder = get_folder_selection()
    if not source_folder:
        return

    # Step 2 -- Create timestamped output folder
    organized_folder = create_organized_folder(source_folder)
    if not organized_folder:
        return

    # Step 3 -- Scan and group image files by extension
    image_files = get_image_files(source_folder)
    if not image_files:
        print('[ERROR] No supported image files found in the selected folder.')
        return

    # Step 4 -- Create one subfolder per image type
    subfolders = create_subfolders(organized_folder, image_files)

    # Step 5 -- Copy all images to their type subfolders
    copied_count, failed_count = copy_files(source_folder, image_files,
                                             subfolders)

    # Step 6 -- Print completion summary
    print('\n' + '=' * 50)
    print('[DONE]  Organisation complete!')
    print(f'[INFO]  Files copied successfully : {copied_count}')

    if failed_count > 0:
        print(f'[WARN]  Files that failed to copy : {failed_count}')

    print(f'[INFO]  Organised folder           : {organized_folder}')

    # Show a GUI info dialog so the user gets a clear completion signal
    root = tk.Tk()
    root.withdraw()
    messagebox.showinfo(
        'Organisation Complete',
        f'Successfully organised {copied_count} image file(s)!\n\n'
        f'Output folder:\n{organized_folder}'
    )


# ===========================================================================
# Entry-point guard -- main() runs only when executed directly
# ===========================================================================
if __name__ == '__main__':
    main()
